﻿/* Copyright 2010 Igor Francé
 * 
 * This file is part of ECMADoc (http://ecmadoc.codeplex.com).
 * 
 * ECMADoc is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License 
 * as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
 * 
 * ECMADoc is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty
 * of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
 * 
 * You should have received a copy of the GNU General Public License along with ECMADoc. 
 * If not, see http://www.gnu.org/licenses/.
 */
namespace EcmaDoc.Core.Configuration
{
	using System;
	using System.Collections.Generic;
	using System.ComponentModel;
	using System.Drawing.Design;
	using System.IO;
	using System.Reflection;
	using System.Text;
	using System.Windows.Forms.Design;
	using System.Xml;
	using System.Xml.Schema;

	/// <summary>
	/// Defines the project configuration options.
	/// </summary>
	[DefaultProperty("Name")]
	public class ProjectInfo
	{
		private const string BaseEcma3 = @"$Resources/Base/JavaScript.js";
		private const string SchemaPath = @"Resources\Schemas\Project.xsd";

		/// <summary>
		/// Initializes a new instance of the <see cref="ProjectInfo"/> class.
		/// </summary>
		public ProjectInfo()
		{
			this.Name = "New project";
			this.Theme = "default";
			this.FileExtension = ".html";
			this.Locations = new List<PathInfo>();
			this.Files = new List<string> { Utilities.ExpandPath(BaseEcma3) };
			this.Config = ParserConfig.Global;
			this.OutputPath = "apidocs";
		}

		/// <summary>
		/// Initializes a new instance of the <see cref="ProjectInfo"/> class, using the configuration from the specified
		/// <paramref name="configPath"/>.
		/// </summary>
		/// <param name="configPath">The path to the project configuration document.</param>
		public ProjectInfo(string configPath)
			: this()
		{
			if (string.IsNullOrEmpty(configPath))
				throw new ArgumentNullException("configPath");

			this.SourcePath = configPath;

			XmlDocument configNode = new XmlDocument();
			configNode.Load(configPath);

			this.Parse(configNode);
		}

		/// <summary>
		/// Initializes a new instance of the <see cref="ProjectInfo"/> class, using the specified configuration document.
		/// </summary>
		/// <param name="configDoc">The XML document that contains the project configuration options.</param>
		public ProjectInfo(XmlDocument configDoc)
		{
			if (configDoc == null)
				throw new ArgumentNullException("configDoc");

			this.Parse(configDoc);
		}

		#region Properties

		/// <summary>
		/// Gets or sets the name of this project
		/// </summary>
		[Category("General")]
		[DisplayName("Name")]
		[DefaultValue("New Project")]
		[Description("The name of this project.")]
		public string Name { get; set; }

		/// <summary>
		/// Gets or sets the output theme to use with this project.
		/// </summary>
		[Browsable(true)]
		[Category("General")]
		[DisplayName("Theme")]
		[DefaultValue("default")]
		[Description("The theme of the documentation that will be generated.")]
		[Editor(typeof(OutputThemeEditor), typeof(UITypeEditor))]
		public string Theme { get; set; }

		/// <summary>
		/// Gets or sets a value indicating whether objects in this project can have more than one base type.
		/// </summary>
		/// <value>
		/// <c>true</c> if objects in this project can have more than one base type; otherwise, <c>false</c>.
		/// </value>
		[Browsable(true)]
		[Category("General")]
		[DisplayName("Use multiple inheritance")]
		[DefaultValue(false)]
		[Description("If true, object can have more than one base type.")]
		public bool UseMultipleInheritance { get; set; }

		/// <summary>
		/// Gets or sets the path to save the output files to.
		/// </summary>
		[Browsable(true)]
		[Category("Output")]
		[DisplayName("Output path")]
		[Description("The directory to generate the documentation in.")]
		[Editor(typeof(FolderNameEditor), typeof(UITypeEditor))]
		public string OutputPath { get; set; }

		/// <summary>
		/// Gets or sets the file extension to use when saving the output files.
		/// </summary>
		[Browsable(true)]
		[Category("Output")]
		[DisplayName("File extension")]
		[DefaultValue(".html")]
		[Description("The file extension of files that will be generated.")]
		public string FileExtension { get; set; }

		/// <summary>
		/// Gets or sets a value indicating whether source documents will appear in the generated output.
		/// </summary>
		/// <value>
		/// <c>true</c> if source documents will appear in the generated output; otherwise, <c>false</c>.
		/// </value>
		[Browsable(true)]
		[Category("Output")]
		[DisplayName("Output documents")]
		[DefaultValue(true)]
		[Description("If true, source documents will appear in the generated output.")]
		public bool OutputDocuments { get; set; }

		/// <summary>
		/// Gets or sets a value indicating whether class members will appear on the same page as the class itself.
		/// </summary>
		/// <value>
		/// <c>true</c> if class members will appear on the same page as the class itself; otherwise, <c>false</c>.
		/// </value>
		[Browsable(true)]
		[Category("Output")]
		[DisplayName("Output members on one page")]
		[DefaultValue(true)]
		[Description("If true, class members will appear on the same page as the class itself.")]
		public bool SinglePageMemberDetail { get; set; }

		/// <summary>
		/// Gets or sets a value indicating whether private members will appear in the output.
		/// </summary>
		/// <value>
		/// <c>true</c> if private members will not appear in the output; otherwise, <c>false</c>.
		/// </value>
		[Browsable(true)]
		[Category("Output")]
		[DisplayName("Output private members")]
		[DefaultValue(true)]
		[Description("If false, private members will not appear in the output.")]
		public bool OutputPrivateMembers { get; set; }

		/// <summary>
		/// Gets or sets a value indicating whether private members will be visible in the generated pages.
		/// </summary>
		/// <value><c>true</c> if private members will be visible in the generated pages; otherwise, <c>false</c>.</value>
		[Browsable(true)]
		[Category("Output")]
		[DisplayName("Show private members")]
		[DefaultValue(true)]
		[Description("If false, private members will by default not be visible in the generated pages. They can still be toggled from the page UI. This property has no effect if 'Output private members' is false.")]
		public bool ShowPrivateMembers { get; set; }

		/// <summary>
		/// Gets or sets the value inidicating how to group members in the generated pages.
		/// </summary>
		[Browsable(true)]
		[Category("Output")]
		[DisplayName("Group members by")]
		[DefaultValue(GroupMembersBy.None)]
		[Description("Controls how the members are grouped in the table of contents document.")]
		public GroupMembersBy GroupMembersBy { get; set; }

		/// <summary>
		/// Gets the files statically assigned to this project.
		/// </summary>
		[Browsable(false)]
		public List<string> Files { get; private set; }

		/// <summary>
		/// Gets the locations assigned to this project.
		/// </summary>
		[Browsable(false)]
		public List<PathInfo> Locations { get; private set; }

		/// <summary>
		/// Gets the path that this project configuration was opened from.
		/// </summary>
		[Browsable(false)]
		public string SourcePath { get; private set; }

		[Browsable(false)]
		internal ParserConfig Config { get; private set; }

		[Browsable(false)]
		internal string ArtifactPath
		{
			get
			{
				return Path.Combine(OutputPathFull, "artifacts");
			}
		}

		[Browsable(false)]
		internal string OutputPathFull
		{
			get
			{
				return ExpandPath(OutputPath);
			}
		}

		private string BasePath
		{
			get
			{
				return Path.GetDirectoryName(this.SourcePath);
			}
		}

		[Browsable(false)]
		private XmlNode ConfigNode { get; set; }

		#endregion

		/// <summary>
		/// Gets the file name for project's global html files (such as contents or member index). 
		/// </summary>
		/// <param name="pageType">The type of the projeect page for which to return its file name.</param>
		/// <param name="expand">If <c>true</c>, the result value will be prefixed with the output path.</param>
		/// <returns>The file name for project's global html files.</returns>
		public string GetProjectFileName(HtmlPageType pageType, bool expand)
		{
			string path = null;
			switch (pageType)
			{
				case HtmlPageType.Contents:
					path = "contents" + this.FileExtension;
					break;
				case HtmlPageType.Default:
					path = "index" + this.FileExtension;
					break;
				case HtmlPageType.Index:
					path = "memberindex" + this.FileExtension;
					break;
				case HtmlPageType.Project:
					path = "project" + this.FileExtension;
					break;
			}

			if (path != null && expand)
				return ExpandOutputPath(path);

			return path;
		}

		/// <summary>
		/// Adds the specified file to the project.
		/// </summary>
		/// <param name="path">The path of the file to add.</param>
		/// <returns><c>true</c> if the file was added, otherwise <c>false</c>.</returns>
		public bool AddFile(string path)
		{
			bool added = false;
			if (!Files.Contains(path))
			{
				string relative = ShrinkPath(path);
				if (!Files.Contains(relative))
				{
					Files.Add(relative);
					added = true;
				}
			}

			return added;
		}

		/// <summary>
		/// Adds the specified directory to the project.
		/// </summary>
		/// <param name="path">The path of the directory to add.</param>
		/// <returns><c>true</c> if the directory was added, otherwise <c>false</c>.</returns>
		public bool AddLocation(string path)
		{
			bool added = false;
			if (!this.Locations.Contains(path))
			{
				string relative = ShrinkPath(path);
				if (!Files.Contains(relative))
				{
					Locations.Add(new PathInfo(relative));
					added = true;
				}
			}

			return added;
		}

		/// <summary>
		/// Returns the full, physical version of the supplied path, valid in the current environment.
		/// </summary>
		/// <param name="path">The path to expand or resolve</param>
		/// <returns>The full, physical version of the supplied path, valid in the current environment.</returns>
		public string ResolvePath(string path)
		{
			if (string.IsNullOrEmpty(path))
				throw new ArgumentNullException("path");

			if (path.ToLower().IndexOf("$resources") == 0)
			{
				string resourceRoot = GetResourcePath();
				string resourcePath = path.ToLower().Replace("$resources", string.Empty);
				if (resourcePath.IndexOfAny(new[] { '/', '\\' }) == 0)
					resourcePath = resourcePath.Substring(1);

				path = new DirectoryInfo(Path.Combine(resourceRoot, resourcePath)).FullName;
			}
			else
			{
				path = ExpandPath(path);
			}

			return path;
		}

		/// <summary>
		/// Saves this instance to the associated <see cref="SourcePath"/>.
		/// </summary>
		public void Save()
		{
			if (this.SourcePath != null)
				Save(this.SourcePath);
		}

		/// <summary>
		/// Saves this instance to the specified <paramref name="path"/>.
		/// </summary>
		/// <param name="path">The path to save this instance to.</param>
		public void Save(string path)
		{
			if (string.IsNullOrEmpty(path))
				throw new ArgumentNullException("path");

			this.SourcePath = path;
			this.ShrinkPaths();

			XmlDocument self = this.ToXml();
			self.Save(this.SourcePath);

			this.Parse(self);
		}

		/// <summary>
		/// Expands the specified relative <paramref name="path"/> to a qualified path based on the location of this 
		/// configuration instance.
		/// </summary>
		/// <param name="path">The path to convrt.</param>
		/// <returns>The specified relative <paramref name="path"/>, exppanded to a qualified path based on the 
		/// location of this project configuration instance</returns>
		public string ExpandOutputPath(string path)
		{
			return new FileInfo(Path.Combine(OutputPathFull, path)).FullName;
		}

		/// <summary>
		/// Expands a child path (path relative to the location of the config file) to a full physical path to it.
		/// </summary>
		/// <param name="path">The path relative to the config file.</param>
		/// <returns>Fully qualified physical path to the file.</returns>
		public string ExpandPath(string path)
		{
			if (string.IsNullOrEmpty(path))
				throw new ArgumentNullException("path");

			if (string.IsNullOrEmpty(this.SourcePath))
				return path;

			if (Path.IsPathRooted(path))
				return path;

			return new FileInfo(Path.Combine(BasePath, path)).FullName;
		}

		/// <summary>
		/// Converts a full, physical path to a file into a path relative to the location of the config file.
		/// </summary>
		/// <param name="path">Fully qualified physical path to the file.</param>
		/// <returns>The path relative to the config file.</returns>
		public string ShrinkPath(string path)
		{
			if (string.IsNullOrEmpty(path))
				throw new ArgumentNullException("path");

			if (string.IsNullOrEmpty(this.SourcePath))
				return path;

			if (!Path.IsPathRooted(path))
				return path;

			if (Path.GetPathRoot(path).ToLower() != Path.GetPathRoot(BasePath).ToLower())
				return path;

			return GetRelativePath(path, BasePath).Replace('\\', '/');
		}

		private static string GetRelativePath(string absolutePath, string relativeTo)
		{
			if (string.IsNullOrEmpty(absolutePath))
				throw new ArgumentNullException("absolutePath");
			if (string.IsNullOrEmpty(relativeTo))
				throw new ArgumentNullException("relativeTo");

			string[] absoluteDirectories = relativeTo.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
			string[] relativeDirectories = absolutePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);

			int length = absoluteDirectories.Length < relativeDirectories.Length 
				? absoluteDirectories.Length 
				: relativeDirectories.Length;

			int lastCommonRoot = -1;
			int index;

			for (index = 0; index < length; index++)
			{
				if (absoluteDirectories[index].ToLower() != relativeDirectories[index].ToLower())
					break;

				lastCommonRoot = index;
			}

			if (lastCommonRoot == -1)
				return absolutePath;

			StringBuilder relativePath = new StringBuilder();
			for (index = lastCommonRoot + 1; index < absoluteDirectories.Length; index++)
				if (absoluteDirectories[index].Length > 0)
					relativePath.Append("..\\");

			for (index = lastCommonRoot + 1; index < relativeDirectories.Length - 1; index++)
				relativePath.Append(relativeDirectories[index] + "\\");

			relativePath.Append(relativeDirectories[relativeDirectories.Length - 1]);
			return relativePath.ToString();
		}

		private static string GetResourcePath()
		{
			string currentFolder = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
			return new DirectoryInfo(Path.Combine(currentFolder, "Resources")).FullName;
		}

		private void ShrinkPaths()
		{
			for (int i = 0; i < Files.Count; i++)
				Files[i] = ShrinkPath(Files[i]);

			for (int i = 0; i < Locations.Count; i++)
				Locations[i].Path = ShrinkPath(Locations[i].Path);
		}

		private void Parse(XmlDocument source)
		{
			if (source == null)
				throw new ArgumentNullException("source");

			source.Schemas.Add(ParserConfig.EcmaDocNamespace, Utilities.ExpandPath(SchemaPath));
			source.Validate(delegate(object sender, ValidationEventArgs e)
			{
				if (e.Exception != null)
				{
					throw new XmlSchemaValidationException(
						string.Format("Project schema validation {0}: {1}", e.Severity, e.Message), e.Exception);
				}
			});

			XmlNamespaceManager nm = ParserConfig.NamespaceManager;
			XmlElement projectNode = source.SelectSingleNode("c:project", nm) as XmlElement;
			if (projectNode == null)
				throw new ConfigurationException("The project configuration doesn't contain the required project node");

			XmlElement sourcesNode = projectNode.SelectSingleNode("c:sources", nm) as XmlElement;
			XmlElement outputNode = projectNode.SelectSingleNode("c:output", nm) as XmlElement;
			XmlElement optionsNode = projectNode.SelectSingleNode("c:options", nm) as XmlElement;
			XmlElement parserNode = projectNode.SelectSingleNode("c:parser", nm) as XmlElement;
			XmlNodeList fileNodes = sourcesNode.SelectNodes("c:file", nm);
			XmlNodeList folderNodes = sourcesNode.SelectNodes("c:folder", nm);

			Name = projectNode.GetAttribute("name");
			OutputPath = outputNode.GetAttribute("path");
			GroupMembersBy = GroupMembersBy.None;
			SinglePageMemberDetail = true;
			Config = ParserConfig.Create(parserNode);
			ConfigNode = parserNode;

			if (!string.IsNullOrEmpty(outputNode.GetAttribute("groupBy")))
				GroupMembersBy = (GroupMembersBy) Enum.Parse(typeof(GroupMembersBy), outputNode.GetAttribute("groupBy"), true);

			if (!string.IsNullOrEmpty(outputNode.GetAttribute("fileExtension")))
				FileExtension = outputNode.GetAttribute("fileExtension");

			this.UseMultipleInheritance = optionsNode.GetAttribute("useMultipleInheritance") == "true";
			this.OutputPrivateMembers = optionsNode.GetAttribute("documentPrivateMembers") == "true";
			this.OutputDocuments = optionsNode.GetAttribute("outputDocuments") == "true";
			this.ShowPrivateMembers = optionsNode.GetAttribute("showPrivateMembers") == "true";
			this.SinglePageMemberDetail = optionsNode.GetAttribute("outputMembersOnSinglePage") == "true";

			Theme = outputNode.SelectSingleNode("c:theme", nm).InnerText;
			Locations = new List<PathInfo>();
			Files = new List<string>();

			foreach (XmlElement folder in folderNodes)
				Locations.Add(new PathInfo(folder));

			foreach (XmlElement file in fileNodes)
				Files.Add(file.InnerText);
		}

		private XmlDocument ToXml()
		{
			const string nm = ParserConfig.EcmaDocNamespace;

			XmlDocument self = new XmlDocument();
			self.AppendChild(self.CreateProcessingInstruction("xml", "version=\"1.0\" encoding=\"utf-8\""));

			XmlElement project = (XmlElement) self.AppendChild(self.CreateElement("project", nm));
			XmlElement output = (XmlElement) project.AppendChild(self.CreateElement("output", nm));
			XmlElement sources = (XmlElement) project.AppendChild(self.CreateElement("sources", nm));
			XmlElement options = (XmlElement) project.AppendChild(self.CreateElement("options", nm));
			XmlElement theme = (XmlElement) output.AppendChild(self.CreateElement("theme", nm));

			project.SetAttribute("name", this.Name);

			output.SetAttribute("path", this.OutputPath);
			output.SetAttribute("fileExtension", this.FileExtension);
			output.SetAttribute("groupBy", this.GroupMembersBy.ToString().ToLower());

			theme.InnerText = this.Theme;

			options.SetAttribute("useMultipleInheritance", this.UseMultipleInheritance ? "true" : "false");
			options.SetAttribute("outputMembersOnSinglePage", this.SinglePageMemberDetail ? "true" : "false");
			options.SetAttribute("outputDocuments", this.OutputDocuments ? "true" : "false");
			options.SetAttribute("documentPrivateMembers", this.OutputPrivateMembers ? "true" : "false");
			options.SetAttribute("showPrivateMembers", this.ShowPrivateMembers ? "true" : "false");
			
			foreach (PathInfo info in this.Locations)
			{
				XmlElement folderNode = self.CreateElement("folder", nm);
				folderNode.InnerText = info.Path;
				if (!string.IsNullOrEmpty(info.Extensions))
					folderNode.SetAttribute("extensions", info.Extensions);
				if (!string.IsNullOrEmpty(info.Excludes))
					folderNode.SetAttribute("exclude", info.Excludes);

				sources.AppendChild(folderNode);
			}

			foreach (string file in this.Files)
			{
				XmlElement fileNode = self.CreateElement("file", nm);
				fileNode.InnerText = file;
				sources.AppendChild(fileNode);
			}

			if (ConfigNode != null)
				project.AppendChild(self.ImportNode(ConfigNode, true));

			return self;
		}

		/// <summary>
		/// Provides a class that can be used with editing of the project output theme value.
		/// </summary>
		private class OutputThemeEditor : UITypeEditor
		{
			/// <summary>
			/// Gets the editor style used by the <see cref="UITypeEditor.EditValue(IServiceProvider,Object)"/> method.
			/// </summary>
			/// <param name="context">An <see cref="ITypeDescriptorContext"/> that can be used to gain additional 
			/// context information.</param>
			/// <returns>
			/// A <see cref="UITypeEditorEditStyle"/> value that indicates the style of editor used by the 
			/// <see cref="UITypeEditor.EditValue(IServiceProvider,Object)"/> method. If the 
			/// <see cref="UITypeEditor"/> does not support this method, then <see cref="UITypeEditor.GetEditStyle()"/> 
			/// will return <see cref="UITypeEditorEditStyle.None"/>.
			/// </returns>
			public override UITypeEditorEditStyle GetEditStyle(ITypeDescriptorContext context)
			{
				return UITypeEditorEditStyle.DropDown;
			}
		}
	}
}